Add nhflotools.lakes (Bergen pond/lake stage boundary) + polder DRN exclude - #58
Conversation
Extract the lake-cell carve into nhflotools.lakes with a single aggregator (_aggregate_lake_cells) shared by carve_lake_cells, riv_from_lakes_pwn (per-lake RIV stage boundary) and recharge_pond_mask, so the carved-cell set, the stage reach set and the recharge-exclusion mask are equal by construction. Add an optional exclude mask to polder.drn_from_waterboard_data that nulls drn_cond and drn_elev at excluded cells so a reach is dropped whether its stage came from HHNK peilgebied data or the maaiveld fallback. Add unit tests on a synthetic disv grid.
_aggregate_lake_cells operated on an empty geometry column when no cell cleared the coverage threshold (empty gdf, every piece missing strt/botm, or all below min_area_fraction), raising 'can only use area methods with polygon geometries'. Return an empty cellid-indexed frame instead, so carve_lake_cells carves nothing and riv_from_lakes_pwn returns None. Add regression tests for both empty paths.
|
Cross-ref: the top-lifecycle architecture note on NHFLO/models#126 places
|
…ariant Responds to the review of #58: - _aggregate_lake_cells now returns one record per (cellid, identificatie) plus a per-cell coverage Series. Lakes sharing a grid cell are no longer merged: each keeps its own RIV reach with its own stage, bed (rbot), conductance and boundname, so per-lake budgets stay attributable and a future MVR mover/weir or LAK configuration can address lakes individually. The cell is still carved once, to the deepest bed among its lakes. - carve_lake_cells stores ds['lake_coverage'] (combined stage-carrying lake coverage per cell, also below the carve threshold) next to ds['lake_cell'], and documents its lifecycle contract: called after the layer model is finalized, lowering-only (see the consolidated adjust-top note on NHFLO/models#126). - recharge_pond_mask gains a keyword-only fractional=True mode: 1.0 at carved lake and panden cells, the open-water coverage fraction at cells below the carve threshold, so recharge can be scaled by 1 - fraction instead of the all-or-nothing boolean exclusion. - lak_from_lakes_pwn builds the LAK alternative from the same aggregator, with an effective bed resistance that reproduces the RIV summed-piece conductance and from_ds meteorology. Verified: recharge double-counting is impossible in the current build -- KNMI recharge (method='linear') nets Makkink evaporation into the single recharge variable and no EVT package is built, so masking a cell removes its meteoric term exactly once. Tests: regression for the cross-lake merge (fails on the previous aggregation, including the per-lake rbot in a shared cell), fractional mask, LAK connection/conductance equivalence; suite mutation-tested (13 kernel mutations, all caught).
…om_gdf lak_from_lakes_pwn wrapped nlmod's lake builder; replace it with lak_gdf_from_lakes_pwn, which only aggregates the lakes_pwn pieces to the per-(cell, lake) frame that nlmod.gwf.lake_from_gdf consumes (effective clake = cell_area / sum(piece_area/clake), one exact strt per lake, outlet columns carried through, None when no cell clears the coverage threshold). The model script now calls nlmod.gwf.lake_from_gdf and copy_meteorological_data_from_ds directly.
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def _aggregate_lake_cells(ds, gdf_lake_grid, min_area_fraction=0.5): |
There was a problem hiding this comment.
Does this mean that lake cells are merged? I think it might become more difficult to configure the mover package, as they are assigned to individual lakes. Same goes for weirs
There was a problem hiding this comment.
Confirmed and fixed in 44a9b1a: aggregation is now per (cellid, identificatie), so lakes sharing a cell each keep their own reach, stage, rbot, cond and boundname — MVR/weir and LAK config can address individual lakes. Regression test fails on the old blend."
| ) | ||
|
|
||
|
|
||
| def carve_lake_cells(ds, gdf_lake_grid, min_area_fraction=0.5): |
There was a problem hiding this comment.
Not sure from where this function is called, but I presume from the function that created the top in nhflotools
There was a problem hiding this comment.
Called from the model script directly after the layer model is finalized, before any package build — not from a top-creation function. Lifecycle contract now in the docstring; folding it into one consolidated adjust-top step stays the models#126 follow-up.
| return riv | ||
|
|
||
|
|
||
| def recharge_pond_mask(ds, panden_riv=None): |
There was a problem hiding this comment.
Thoroughly review MF6 and flopy to confirm that all recharge and evaporation is not already deducted from the EVT or RECH packages.
There was a problem hiding this comment.
Note that fractional overlap should also be possible
What
Adds
nhflotools.lakesand a polder-DRNexcludeoption — the nhflotools half of the Bergen pond/lake stage-boundary fix (model-script side: NHFLO/models#112 and #119, wired up in NHFLO/models#127).nhflotools/lakes.py— a single source-of-truth aggregator_aggregate_lake_cells(one record per lake per cell, plus a per-cell coverage Series) shared by:carve_lake_cells(ds, gdf_lake_grid, min_area_fraction=0.5)— lowers the model top to the deepest lake bottom in cells whose combined lake coverage clears the threshold, and stores two markers: booleands['lake_cell']and floatds['lake_coverage'](coverage fraction, also below the carve threshold);riv_from_lakes_pwn(...)— holds the carved cells at their prescribed stage with one RIV reach per lake per cell (stage=strt,rbot=that lake's bed,cond=Σ piece_area/clake, boundname=identificatie). Lakes sharing a cell are never merged, so per-lake budgets stay attributable and a future MVR mover/weir setup can address individual lakes;lak_gdf_from_lakes_pwn(...)— LAK variant for detailed studies, as data prep only: it aggregates the same aggregator output to the per-(cell, lake) frame thatnlmod.gwf.lake_from_gdfconsumes (the model script builds the package and its meteorology directly with nlmod), with an effective bed resistance whosebedleak * cell_areareproduces the RIV summed-piece conductance and one exactstrtper lake. The total exchange is nevertheless somewhat weaker than the RIV's: MF6 places the connected cell's half-cell vertical resistance (0.5*Δz/k33) in series with the lakebed for VERTICAL connections (physics-review verified against MF6 to machine precision; 12–16% lower on the PWN layers, and exact equivalence viabedleakis impossible whereclake < 0.5*Δz/k33, e.g. theclake=1Vlotter lakes);recharge_pond_mask(ds, panden_riv=None, *, fractional=False)— flags cells whose meteoric input is carried by a stage boundary so RCH can exclude them.fractional=Truereturns a per-cell fraction instead (1.0 at carved/panden cells, the open-water coverage at cells below the carve threshold) forrecharge * (1 - fraction)scaling.Because the carved-cell set and the stage-boundary cell set come from the same aggregator, they are equal by construction — no carved cell is left without a stage, and no stage reach lands on an un-carved cell.
nhflotools/polder.py—drn_from_waterboard_data(..., exclude=None): nullsdrn_cond/drn_elevat excluded cells (backward-compatible defaultNone), so carved lake cells are handed to the dedicated stage boundary instead of a polder drain sitting at the pre-carve maaiveld.Why
In the default Bergen extent nothing maintains the pond/lake stages (
strt1.92–3.30 m NAP): the panden RIV lies outside the extent, the LAK block is commented out, and no RWS/HHNK boundary touches a lake cell — so carved cells behaved as dry confined depressions, recharge was double-counted over them, and the polder fallback drain sat at the pre-carve surface. See NHFLO/models#112 / #119.Design decisions (reviewed)
rbot =the lake's own bed caps bed infiltration once the head drops below the lakebed (perched-pond behaviour) while draining freely above the stage;cond = piece_area/clakematches how LAK forms lakebed conductance. In a cell carved by a single lake (the usual case)rbotequals the carved top; where lakes share a cell, the cell is carved to the deepest bed and the shallower lake's reach keeps its own higherrbot— physically each lake perches at its own bed. Confirmed by independent physics review (exact flux equivalence with the previous blended reach for uniformclake; strictly more faithful when beds differ).'-1'string, [lakes_pwn] External outlet encoded as string '-1' crashes nlmod.gwf.lake.lake_from_gdf data#72) remain out of scope. Known limitation (docstring-documented): on a cell shared by two lakes, MF6 applies each lake's RAINFALL over the full cell area while RCH excluded the cell once, over-applying the meteoric term there — no currentlakes_pwncell is shared.nlmod.read.knmi.get_recharge(defaultmethod='linear') nets Makkink evaporation into the singlerechargevariable, no EVT package is built anywhere in the script, and the boolean RCH mask drops a cell's entire meteoric term exactly once; the prescribed-stage boundary then carries the lake's own water balance.Verification
ruff format/ruff checkclean.disvgrid (no solver, no PWN data, no network): coverage threshold (strict>on combined coverage), deepest-bed carve, unifieddropna, carve-set == stage-set identity, per-lake aggregation (pieces merge within a lake, never across; regression test fails on the previous per-cell blend, including the per-lakerbotin a shared cell), top-active-layer placement, boolean + fractional recharge masks, LAK connection/conductance/from_ds-meteorology equivalence, DRN cond/elev nulling, and the empty/all-below-threshold edge.strt) is unverified here. Pending.Follow-ups (from review, non-blocking)
fractional=Truerecharge scaling into the model script (ds['recharge'] *= 1 - fraction; the per-cell transient KNMI recharge path honours per-cell scaling) — currently the boolean mask is used.ds.attrs['ssm_sources'](SSM path) and cover thetransport=0branch.lak_gdf_from_lakes_pwnonce Aggregate lake pieces per cell in lake_from_gdf, like RIV/DRN celldata aggregation gwmod/nlmod#582 lands (piece→cell aggregation + strt float tolerance insidelake_from_gdf, mirroring the RIV/DRNsurface_water.aggregateidiom): the effective-clakeand strt-collapse lines move upstream and the helper reduces to threshold-filtering pieces from the shared aggregator.strt.panden.riv_from_oppervlakte_pwn.set_model_topcall (it is a lossy one-way ratchet; monotonic lowering is safe, raising is not) — see the architecture note on NHFLO/models#126.Relationship
This must land before NHFLO/models#127 (the model script imports
carve_lake_cells,riv_from_lakes_pwn,recharge_pond_mask,lak_gdf_from_lakes_pwn). Draft until the end-to-end verification above is done.